Redux Toolkit
以下文件描述的前因後果請參閱 Introduction
安裝
pnpm add @reduxjs/toolkit react-redux
建立所需的 Slice
使用 createSlice 來簡化 reducer 和 action 的建立
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// ...
const initialState: ErrorSlice = {
errorData: {}
};
const errorSlice = createSlice({
name: 'entry/error',
initialState,
reducers: {
onErrorDataChange: (state, action: PayloadAction<{ [key: string]: ErrorType }>) => {
return {
...state,
errorData: { ...state.errorData, ...action.payload }
};
}
}
});
export const { onErrorDataChange } = errorSlice.actions;
export default errorSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// ...
const initialState: FetchStatusSlice = {
fetchStatus: {}
};
const fetchStatusSlice = createSlice({
name: 'entry/fetchStatus',
initialState,
reducers: {
onFetchStatusChange: (state, action: PayloadAction<{ [key: string]: boolean }>) => {
return {
...state,
fetchStatus: { ...state.fetchStatus, ...action.payload }
};
}
}
});
export const { onFetchStatusChange } = fetchStatusSlice.actions;
export default fetchStatusSlice.reducer;
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// ...
const initialState: UserSlice = {
currentUser: undefined
};
const userSlice = createSlice({
name: 'entry/user',
initialState,
reducers: {
onCurrentUserChange: (state, action: PayloadAction<UserType | undefined>) => {
return {
...state,
currentUser: action.payload
};
}
}
});
export const { onCurrentUserChange } = userSlice.actions;
export default userSlice.reducer;
使用 createApi 來建立一個 ApiSlice
先建立一個 baseApi.ts 設定共用的 api 設定
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { casApiUrl } from '@/config';
import { getAccessTokenByCookie } from '@/utils/token.utility.ts';
export interface ResponseType {
data: any;
statusCode: number;
}
export const transformResponse = <T>(response: ResponseType): T => {
return response.data;
};
export const transformErrorResponse = (response: any) => {
return response.data?.error || response.error;
};
export const baseCasApi = createApi({
baseQuery: fetchBaseQuery({
baseUrl: casApiUrl,
prepareHeaders: headers => {
const accessToken = getAccessTokenByCookie();
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
return headers;
},
credentials: 'include'
}),
reducerPath: 'baseCasApiReducer',
endpoints: () => ({})
});
- cookie 使用
js-cookie
來處理相關的 func - 在 headers 的部分, 當有取得
accessToken
時, 就會將Authorization
加入到 headers 中 credentials
用來控制跨域 (cross-site) 的請求是否應該攜帶認證資訊 (例如 cookies 和 HTTP authentication 等), 當設定為include
時則即使是跨域的請求, 也會攜帶這些認證資訊
然後使用 injectEndpoints 基於 baseCasApi
建立所需的 authApi
:
//...
export const authApi = baseCasApi.injectEndpoints({
endpoints: build => ({
fetchProfile: build.query<UserType, any>({
query: () => {
return {
url: '/auth/profile',
method: 'GET',
headers: {
'content-type': apiHeadersType.jsonType
}
};
},
transformResponse,
transformErrorResponse,
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
dispatch(onCurrentUserChange(data));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
} catch (err) {
console.error(err);
}
}
}),
renewToken: build.query<string, void>({
query: () => ({
url: `/auth/access-token/renew`,
method: 'GET',
headers: {
'content-type': 'application/json'
}
}),
transformResponse,
transformErrorResponse
}),
stVerify: build.query<string, { st: string; search: string; navigate: (url: string) => void }>({
query: ({ st }) => {
return {
url: `/auth/access-token/${st}`,
method: 'GET',
headers: {
'content-type': apiHeadersType.jsonType
}
};
},
transformResponse,
transformErrorResponse,
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
saveAccessTokenCookie(data);
const params = new URLSearchParams(arg.search);
const queriedStr = Object.fromEntries([...params]);
delete queriedStr.st;
const redirectStr = isEmpty(queriedStr) ? '' : appendStr('', queriedStr);
arg.navigate(`${window.location.pathname}${redirectStr}`);
} catch (err) {
const { error } = err as { error: ErrorType };
dispatch(onCurrentUserChange(undefined));
dispatch(onErrorDataChange({ stVerifyError: error }));
arg.navigate(window.location.pathname);
}
dispatch(onFetchStatusChange({ isStVerifying: false }));
}
})
}),
overrideExisting: false
});
export const { useStVerifyQuery, useFetchProfileQuery } = authApi;
injectEndpoints
允許將新的endpoints
注入到已存在的 API Slice 中 (baseCasApi
), 而不用重新創建整個 API Slice, 這樣可以分開管理不同的 API endpoints 並且按需求來組合transformResponse
及transformErrorResponse
用來處理 response 的資料格式, 可以拉出來統一回傳的格式overrideExisting: false
表示如果已經存在同名的 endpoints 則不會覆蓋它們, 防止無意間覆蓋已經定義的 endpoints
上面設定的 api (fetchProfile, renewToken, stVerify) 所相關的描述可參考實作流程
建立 Reducers
使用 combineReducers
將所有的 slice 進行整合
import { combineReducers } from 'redux';
import errorSlice from '@/store/slice/error.slice.ts';
import fetchStatusSlice from './slice/fetch-status.slice';
import userSlice from './slice/user.slice';
const entityReducer = combineReducers({
error: errorSlice,
fetchStatus: fetchStatusSlice,
user: userSlice
});
建立 Store
使用 configureStore 來建立 store, 然後將剛剛所建立的 reducers 加入到 store 中
import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
import { combineReducers, configureStore, isRejectedWithValue } from '@reduxjs/toolkit';
import { env } from '@/config.ts';
import { baseCasApi } from '@/services/cas';
import { authApi } from '@/services/cas/auth.ts';
import errorSlice, { ErrorType, onErrorDataChange } from '@/store/slice/error.slice.ts';
import { removeAccessTokenCookie, saveAccessTokenCookie } from '@/utils/token.utility.ts';
import fetchStatusSlice, { onFetchStatusChange } from './slice/fetch-status.slice';
import userSlice, { onCurrentUserChange } from './slice/user.slice';
const entityReducer = combineReducers({
error: errorSlice,
fetchStatus: fetchStatusSlice,
user: userSlice
});
interface EndpointAction {
initiate: (arg: any) => Promise<any>;
}
interface MetaArg {
endpointName: keyof typeof baseCasApi.endpoints;
originalArgs: any;
}
export const rtkQueryErrorHandler: Middleware = (api: MiddlewareAPI) => next => async action => {
const { dispatch } = api;
if (isRejectedWithValue(action)) {
const payload = action.payload as ErrorType;
switch (payload.key) {
case 'ACCESS_TOKEN_IS_EXPIRED':
try {
dispatch(onFetchStatusChange({ isProfileFetching: true }));
// Call renewToken API to get a new token
const renewAccessToken = await dispatch<any>(authApi.endpoints.renewToken.initiate()).unwrap();
saveAccessTokenCookie(renewAccessToken);
// Retry the original API request with the new token
const originalRequest = action.meta.arg as MetaArg;
const endpointName = originalRequest.endpointName;
const endpointAction = baseCasApi.endpoints[endpointName] as EndpointAction;
await dispatch<any>(endpointAction.initiate(originalRequest));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
} catch (renewError) {
console.error('Token renewal failed', renewError);
dispatch(onCurrentUserChange(undefined));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
dispatch(onErrorDataChange({ profile: payload }));
}
break;
case 'REFRESH_TOKEN_IS_EXPIRED':
removeAccessTokenCookie();
dispatch(onCurrentUserChange(undefined));
dispatch(onErrorDataChange({ profile: payload }));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
break;
default:
break;
}
}
return next(action);
};
const store = configureStore({
reducer: {
[baseCasApi.reducerPath]: baseCasApi.reducer,
entityReducer
},
middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }).concat(baseCasApi.middleware, rtkQueryErrorHandler),
devTools: env === 'development'
});
export type MainStoreState = ReturnType<typeof store.getState>;
export type MainStoreDispatch = typeof store.dispatch;
export default store;
rtkQueryErrorHandler
是一個 Middleware
, 用來捕捉 rtk-query
的請求發生錯誤時的處理, 這裡我們的目的在於發送請求時會帶著 accessToken
去向 server 取得 user 的資料, 當 accessToken 過期的時候, 可以在這邊進行 renew token
的請求, 如果更新成功取得新的 accessToken, 就可以直接再次發送原本的請求, 就不需要寫額外的邏輯去處理 更新 token 並重新發送上次失敗的請求
在 Redux 中的 middleware 允許在 action 被 dispatch 至 reducer 之前進行一些額外的操作, 例如: log, 錯誤處理, 或者是對 action 進行修改等
另外在:
middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }).concat(baseCasApi.middleware, rtkQueryErrorHandler);
這邊需注意 middleware
的順序 (relative issue)
在 React 中提供 Store
使用 Provider 將 Redux store 提供給整個 React
import { FC, Suspense } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import Spin from '@/components/spin';
import Entry from '@/pages/entry';
import { GlobalProvider } from '@/provider/global.provider.tsx';
import store from './store';
const App: FC = () => {
return (
<Provider store={store}>
<BrowserRouter>
<GlobalProvider>
<Suspense
fallback={
<div className="bg-basic-100 h-screen w-full content-center">
<Spin className="m-auto" />
</div>
}
>
<Entry />
</Suspense>
</GlobalProvider>
</BrowserRouter>
</Provider>
);
};
export default App;
建立 Global Provide
import React, { createContext, FC } from 'react';
import { useAuth } from '@/hooks/use-auth';
import { FetchStatusSlice } from '@/store/slice/fetch-status.slice';
import { UserType } from '@/store/slice/user.slice';
export const GlobalContext = createContext<GlobalContextType>({
fetchStatus: {},
currentUser: undefined
});
export const GlobalProvider: FC<{ children: React.ReactNode }> = ({ children }) => {
const { currentUser, fetchStatus } = useAuth();
return (
<GlobalContext.Provider
value={{
currentUser,
fetchStatus
}}
>
{children}
</GlobalContext.Provider>
);
};
這邊使用 Context 來提供 props, 因為當 auth 有所變化的時候希望能更新整個 component tree
使用 Context API 時, provider 傳遞的 props 如果不使用 useMemo
跟 useCallback
來包裝, 每次 provider 的 props 有所變動時, 會導致所有使用到 context 的地方都會被 re-render
useAuth
就是處理與 server 之間 authentication 相關的邏輯
// ...
const fetchStatusSelector = (state: MainStoreState) => state.entityReducer.fetchStatus;
const userSelector = (state: MainStoreState) => state.entityReducer.user;
const reSelector = createSelector(fetchStatusSelector, userSelector, (fetchStatus, user) => ({
currentUser: user.currentUser,
fetchStatus: fetchStatus.fetchStatus
}));
export const useAuth = () => {
// ...
return useSelector(reSelector);
};